Skip to content

feat: shareable reports + self-service subscriptions - #157

Merged
munisp merged 3 commits into
mainfrom
feat/share-and-subscribe-v2
Sep 13, 2026
Merged

feat: shareable reports + self-service subscriptions#157
munisp merged 3 commits into
mainfrom
feat/share-and-subscribe-v2

Conversation

@munisp

@munisp munisp commented Sep 13, 2026

Copy link
Copy Markdown
Owner

What

WP4 — shareable investigation reports + self-service subscriptions (Intelius instant-report + self-serve pricing analogs).

Supersedes #153 (identical code; re-branched from current main so the diff — including drizzle/meta/_journal.json — is purely additive and conflict-free; the migration is 0024, after main's 0023_subject_portal).

server/shareableReports.ts (new, shareableReportsRouter):

  • createShareLink (writeProcedure): verifies the investigation belongs to ctx.tenantId (FOR SHARE inside a tx); issues a bis_sl_<random> token shown exactly once — only its SHA-256 hex digest is persisted (same scheme as apiTokens/openclawEndpoints bearer validation); expiry defaults to 7 days, validated max 30; writes an HMAC-chained audit_log row + publishes REPORT_SHARE_CREATED.
  • getSharedReport (token-authed publicProcedure): hash lookup with unexpired + unrevoked enforced in a single atomic UPDATE … RETURNING that also increments view_count/last_viewed_at (revoked/expired links cannot be raced into extra views). Returns a whitelisted redacted one-pager: subject name, investigation ref, overall risk band (derived from tier/score — raw scores never serialised), per-source screening outcomes reduced to pass/consider/fail (from screening_results via screening_orders, tenant-scoped), field-visit outcome, thin-file flag, generated-at, tenant display name. Referee identities, raw payloads, internal notes, and user IDs are never selected.
  • revokeShareLink / listShareLinks: tenant-scoped; listShareLinks never selects token_hash.

server/selfServiceBilling.ts (new, selfServiceBillingRouter):

  • listPublicPlans (public query): read-only projection of the existing billing_plans catalogue.
  • signup (writeProcedure): client-supplied idempotency key backed by a per-tenant UNIQUE constraint plan_signups.(tenant_id, idempotency_key) — a same-tenant replay returns the original result with idempotent: true and never re-settles payment; a same-tenant concurrent race loses on 23505 and is re-read (tenant-scoped) as the original. A key belonging to another tenant is invisible and behaves as a new key for this tenant — replays can never leak a foreign signupId/billingRef, and tenants cannot squat each other's keys (this holds for zero-price plans too: the synthetic provider_subscription_ref/source_reference are tenant-namespaced as self-serve-free:<tenantId>:<key> / self-serve-signup:<tenantId>:<key> because tenant_subscriptions and billing_entitlements enforce GLOBAL uniques). Plan resolved from billing_plans (active only). Payment goes exclusively through the existing settlePaystackPayment (server/billingSettlement.ts): the billing_payment_intents row must be server-created, tenant-bound, purpose='subscription_invoice', and amount-equal to the plan price before settling; settlement re-verifies with Paystack and posts the deterministic TigerBeetle transfer. Activation mirrors activateManualContract internals transactionally (cancel current sub → insert tenant_subscriptions active → grant billing_entitlements included checks → insert plan_signups). Fail-closed: any payment/ledger failure → typed TRPCError, zero subscription/entitlement, attempt durably recorded as plan_signups.status='payment_failed' + failure audit row. Audit + PLAN_SIGNUP_ACTIVATED event on success.
  • mySubscription, usageSummary: tenant-scoped reads of tenant_subscriptionsbilling_plans and billing_entitlements/billing_usage_events.

drizzle/0024_share_links_and_plan_signups.sql (new) + _journal.json = main's exact current content + idx-24 entry appended (verified byte-level: diff vs main is only the additive entry). plan_signups uniqueness is UNIQUE (tenant_id, idempotency_key). Raw-SQL-only pattern (like the informal_verification tables — applied by pnpm db:migrate).

Why

Closes the WP4 gap: no way to share a redacted investigation result with an external party, and no self-service path onto a commercial plan (today only admin-run activateManualContract).

How tested

Verified against the exact content of this branch (feat/share-and-subscribe-v2 @ b48e539), pnpm install (pnpm 10.27.0), then:

$ pnpm vitest run server/share-subscribe.test.ts
 ✓ server/share-subscribe.test.ts (19 tests) 133ms
 Test Files  1 passed (1)
      Tests  19 passed (19)
   Duration  1.08s (transform 455ms, setup 0ms, import 726ms, tests 133ms)

$ pnpm check   # tsc --noEmit — clean (exit 0)

19 tests cover: token lifecycle (create → view ×2 counted atomically → revoke → rejected; expired rejected; unknown rejected; 30-day cap), redaction shape (exact whitelisted object equality + recursive forbidden-key scan over riskScore|rawResult|referee|notes|createdBy|userId|agentId|token_hash|nin|bvn|… + serialized substring checks), same-tenant idempotency replay (same signupId/subscriptionId, Paystack verify + TigerBeetle transfer called exactly once), cross-tenant idempotency-key replay non-leakage (tenant 2 presenting tenant 1's key gets a brand-new signup — distinct signupId/subscriptionId/billingRef, serialized output contains none of tenant 1's identifiers — plus an SQL-level guard asserting every plan_signups replay lookup carries tenant_id = $1 AND idempotency_key = $2), free-plan cross-tenant same-key coexistence (both tenants activate independently with tenant-namespaced refs; the fake enforces the real GLOBAL uniques on tenant_subscriptions.provider_subscription_ref and billing_entitlements.source_reference), fail-closed payment failure (no sub/entitlement, payment_failed recorded, replay returns original failure without re-settling), cross-tenant denial for share create/revoke/intent binding, and tenant-scoped mySubscription/usageSummary. Tests drive the real routers via createCaller with a stateful in-memory pg handler executing the production SQL (incl. settlePaystackPayment's queries); only the external HTTP boundaries (Paystack verify, TigerBeetle, event processor) are intercepted via stubbed fetch.

Regression (run on same code pre-rebranch): billing.test.ts, billing.debitClaim.test.ts, billing.topup.idempotency.test.ts, paymentReconciliation.test.ts, smoke.comprehensive.test.ts → 131/132 pass. The 1 failure (smoke.comprehensivecreditTenantAccount rejects an unbound legacy reference when TIGERBEETLE_URL is not set) is pre-existing on pristine main (verified against a clean main extract) and is unrelated to this change.

Integration patches (large files, applied by orchestrator)

server/routers.ts and drizzle/schema.ts are too large for MCP push; apply these exactly (verified locally with typecheck + tests):

1. server/routers.ts — imports. Anchor (exists at ~line 142):

import { piiKeyCustodyRouter } from "./piiKeyCustody";

Insert immediately after it:

import { shareableReportsRouter } from "./shareableReports";
import { selfServiceBillingRouter } from "./selfServiceBilling";

2. server/routers.ts — registration. Anchor at the END of the appRouter object (~line 7691):

  fieldEvidence: fieldEvidenceRouter,
  kycDocumentEvidence: kycDocumentEvidenceRouter,
});

Change to:

  fieldEvidence: fieldEvidenceRouter,
  kycDocumentEvidence: kycDocumentEvidenceRouter,
  shareableReports: shareableReportsRouter,
  selfServiceBilling: selfServiceBillingRouter,
});

3. drizzle/schema.ts — append at END of file (optional Drizzle types; runtime code uses raw SQL against the 0024 tables, so this is for type consumers only). Anchor: the final two lines are the InsertForceCreditApproval type exports. Append after them:

// ── report_share_links ─────────────────────────────────────────────────────────
// Tokenised, expiring share links for redacted investigation one-pagers. The
// plaintext token is never stored — only its SHA-256 hex digest (same scheme as
// api_tokens / openclaw bearer validation).
export const reportShareLinks = pgTable("report_share_links", {
  id:               uuid("id").primaryKey(),
  tenantId:         integer("tenant_id").notNull(),
  investigationRef: text("investigation_ref").notNull(),
  tokenHash:        text("token_hash").notNull(),
  createdBy:        integer("created_by"),
  expiresAt:        timestamp("expires_at", { withTimezone: true }).notNull(),
  revokedAt:        timestamp("revoked_at", { withTimezone: true }),
  viewCount:        integer("view_count").notNull().default(0),
  lastViewedAt:     timestamp("last_viewed_at", { withTimezone: true }),
  createdAt:        timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
}, (t) => ({
  report_share_links_token_hash_idx: uniqueIndex("report_share_links_token_hash_idx").on(t.tokenHash),
  report_share_links_tenant_idx:     index("report_share_links_tenant_idx").on(t.tenantId, t.createdAt),
  report_share_links_investigation_idx: index("report_share_links_investigation_idx").on(t.tenantId, t.investigationRef),
}));
export type ReportShareLink = typeof reportShareLinks.$inferSelect;
export type InsertReportShareLink = typeof reportShareLinks.$inferInsert;

// ── plan_signups ───────────────────────────────────────────────────────────────
// Durable, tenant-scoped idempotency record for self-service plan signups. The
// per-tenant unique (tenant_id, idempotency_key) pair guarantees a retried
// signup returns the original result instead of double-charging, and prevents
// cross-tenant replay leaks / key squatting.
export const planSignups = pgTable("plan_signups", {
  id:             uuid("id").primaryKey(),
  tenantId:       integer("tenant_id").notNull(),
  planCode:       text("plan_code").notNull(),
  status:         text("status").notNull(),
  billingRef:     text("billing_ref"),
  idempotencyKey: text("idempotency_key").notNull(),
  createdBy:      integer("created_by"),
  createdAt:      timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
}, (t) => ({
  plan_signups_idempotency_key_unique: uniqueIndex("plan_signups_idempotency_key_unique").on(t.tenantId, t.idempotencyKey),
  plan_signups_tenant_idx:             index("plan_signups_tenant_idx").on(t.tenantId, t.createdAt),
}));
export type PlanSignup = typeof planSignups.$inferSelect;
export type InsertPlanSignup = typeof planSignups.$inferInsert;

Risks

  • signup requires a pre-created billing_payment_intents row with purpose='subscription_invoice' (create it via the existing startPaystackTopup({..., purpose: 'subscription_invoice'}) — exported in billingSettlement.ts). Zero-price plans activate without payment (provider='manual_contract', billing_ref='self-serve-free:<tenantId>:<key>').
  • A payment_failed idempotency key is terminal by design (replay returns the original failure); a genuinely new attempt uses a new key — standard idempotency semantics. Keys are tenant-namespaced, so the same key string in another tenant is an independent signup.
  • getSharedReport is deliberately unauthenticated (bearer-token-in-URL model); the token is 192 bits of entropy, hashed at rest, expiring, revocable.
  • No CI workflow changes (token lacks workflow scope); run pnpm vitest run server/share-subscribe.test.ts in existing test jobs.

@munisp
munisp merged commit a0632ae into main Sep 13, 2026
8 of 10 checks passed
munisp added a commit that referenced this pull request Sep 14, 2026
…gration) (#160)

- WP1 (#154): entitySearchRouter import + appRouter registration
- WP2 (#158): monitoringRouter import + appRouter registration
- WP3 (#155): subjectPortalRouter + computeDataCompleteness imports; subjectPortal registration; removed routers.ts-local getFallbackSuggestion (now shared in server/dataCompleteness.ts); getDataCompleteness delegates to computeDataCompleteness; consentPurposeEnum gains consumer_self_check; subjectAccessTokens/subjectDisputes pgTable declarations (matches drizzle/0023_subject_portal.sql)
- WP4 (#157): shareableReportsRouter + selfServiceBillingRouter imports + registrations; reportShareLinks/planSignups pgTable declarations (matches drizzle/0024_share_links_and_plan_signups.sql)
- WP5 (#156): lookup.phone procedure (gatewayFetch /v1/phone/:number, validated input)

Co-authored-by: bis-integration <integration@bis.local>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant